kim.zhang

风在前,无惧!


  • 首页

  • 标签42

  • 分类12

  • 归档94

  • 搜索

发表于 2021-11-21
本文字数: 11k 阅读时长 ≈ 10 分钟

有时候,我们在登陆页面需要添加验证码,而登陆使用的是Spring Security的登陆流程,就需要我们自定义Spring Security的认证逻辑了。有以下两种方式:

  1. 添加Spring Security过滤器,这种方式有个弊端,每次请求都会通过该过滤器。但实际上,只需要登录请求经过该过滤器即可,其他请求是不需要经过该过滤器的,存在性能的弊端

1. Authentication接口简析

Spring Security的认证逻辑是在接口AuthenticationProvider中:

1
2
3
4
5
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
  • authenticate方法用来验证用户身份
  • supports 则用来判断当前的 AuthenticationProvider 是否支持对应的 Authentication

在 Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息,Authentication 本身是一个接口,它实际上对 java.security.Principal 做的进一步封装,我们来看下 Authentication 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Authentication extends Principal, Serializable {
// 角色列表
Collection<? extends GrantedAuthority> getAuthorities();
// 密码
Object getCredentials();
// 用户携带的详细信息
Object getDetails();
// 当前用户,可能是一个用户名,也可能是一个用户对象
Object getPrincipal();
// 当前用户是否认证成功
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

Authentication作为一个接口,它有很多的实现类:

  • AnonymousAuthenticationToken(匿名登陆)
  • UsernamePasswordAuthenticationToken(账户密码登陆)
  • RememberMeAuthenticationToken(自动登陆)

每一个 Authentication 都有适合它的 AuthenticationProvider 去处理校验。例如处理 UsernamePasswordAuthenticationToken 的 AuthenticationProvider 是 DaoAuthenticationProvider。

在一次完整的认证中,可能包含多个 AuthenticationProvider,而这多个 AuthenticationProvider 则由 ProviderManager 进行统一管理。

2. DaoAuthenticationProvider简析

当我们使用账户密码登陆时,认证逻辑的处理类是DaoAuthenticationProvider,而DaoAuthenticationProvider继承自

AbstractUserDetailsAuthenticationProvider,我们先来看下它的父类,重点关注authenticate和support方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported"));
// 获取用户名
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
// 通过用户名获取用户对象,会调用我们自己在登陆时候写的userDetail中的loadUserByUsername方法
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
// 检验user中的各个账户属性是否正常,例如账户是否被禁用,账户是否过期等
this.preAuthenticationChecks.check(user);
// 密码比对,是一个抽象方法,具体由子类实现
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
// 检查密码是否过期
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
// 是否强制将Authentication中的principal属性设置为字符串,默认值为false
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
// 构建一个新的Authentication返回
return createSuccessAuthentication(principalToReturn, authentication, user);
}

// 当authentication是UsernamePasswordAuthenticationToken的子类时,需要经过DaoAuthenticationProvider的认证逻辑处理
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}

由于 AbstractUserDetailsAuthenticationProvider 已经把 authenticate 和 supports 方法实现了(实现了大部分校验工作),所以在 DaoAuthenticationProvider 中,我们主要关注 additionalAuthenticationChecks 方法即可(密码比对工作):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}

additionalAuthenticationChecks 方法主要用来做密码比对的,逻辑也比较简单,就是调用 PasswordEncoder 的 matches 方法做比对,如果密码不对则直接抛出异常即可。

正常情况下,我们使用用户名/密码登录,最终都会走到这一步。

而 AuthenticationProvider 都是通过 ProviderManager#authenticate 方法来调用的。由于我们的一次认证可能会存在多个 AuthenticationProvider,所以,在 ProviderManager#authenticate 方法中会逐个遍历 AuthenticationProvider,并调用他们的 authenticate 方法做认证,我们来稍微瞅一眼 ProviderManager#authenticate 方法:

1
2
3
4
5
6
7
8
9
10
11
12
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
for (AuthenticationProvider provider : getProviders()) {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
...
}

可以看到,在这个方法中,会遍历所有的 AuthenticationProvider,并调用它的 authenticate 方法进行认证。

3. 自定义认证流程

登录请求是调用 AbstractUserDetailsAuthenticationProvider#authenticate 方法进行认证的,在该方法中,又会调用到 DaoAuthenticationProvider#additionalAuthenticationChecks 方法做进一步的校验,去校验用户登录密码。我们可以自定义一个 AuthenticationProvider 代替 DaoAuthenticationProvider,并重写它里边的 additionalAuthenticationChecks 方法,在重写的过程中,加入验证码的校验逻辑即可。

3.1 验证码生成

导入依赖:

1
2
3
4
5
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>

配置验证码的生成属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class KaptchaConfig {

@Bean
public Producer verifyCode() {
// 验证码属性
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width","150");
properties.setProperty("kaptcha.image.height","50");
properties.setProperty("kaptcha.textproducer.char.string","0123456789");
properties.setProperty("kaptcha.textproducer.char.length","4");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}

编写controller,生成验证码图片,并将生成的验证码放入到session中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Controller
public class KaptchaController {

@Autowired
private Producer producer;

@GetMapping("code.jpg")
public void getVerifyCode(HttpServletResponse response, HttpSession session) {
response.setContentType("image/jpeg");
String text = producer.createText();
// 验证码放入session中
session.setAttribute("verifyCode",text);
// 生成验证码
BufferedImage image = producer.createImage(text);
try (ServletOutputStream outputStream = response.getOutputStream()) {
ImageIO.write(image,"jpg",outputStream);
} catch (IOException e) {
e.printStackTrace();
}
}
}

最后,记得放行验证码的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/code.jpg").permitAll()
.antMatchers("/hello").authenticated()
.antMatchers("/admin").fullyAuthenticated()
.antMatchers("/remember").rememberMe()
.and()
.formLogin()
.permitAll()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}

浏览器访问/code.jpg就可以获取验证码了。

3.2 提供自定义的ProviderManager

前面我们说,所有的 AuthenticationProvider 都是放在 ProviderManager 中统一管理的,所以接下来我们就要自己提供 ProviderManager。

首先,提供自定义的VerifyCodeAuthenticationProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class VerifyCodeAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 完成验证码的校验
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
// 取出session中的验证码
String verfiyCode = (String) request.getSession().getAttribute("verfiyCode");
// 取出前端传递过来的验证码
String code = request.getParameter("code");

if(verfiyCode == null || code == null || !verfiyCode.equals(code)) {
throw new AuthenticationServiceException("验证码错误");
}
// 调用DaoAuthenticationProvider的additionalAuthenticationChecks方法完成密码比对
super.additionalAuthenticationChecks(userDetails, authentication);
}
}

然后注入自定义的 VerifyCodeAuthenticationProvider,这一切操作都在 SecurityConfig 中完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

// 将用户存在内存中,提供一个userDetailService
@Override
@Bean
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("user").password("123").roles("admin").build());
return manager;
}


@Bean
protected VerifyCodeAuthenticationProvider verifyCodeAuthenticationProvider() {
VerifyCodeAuthenticationProvider verifyCodeAuthenticationProvider =
new VerifyCodeAuthenticationProvider();
// 需要提供userDetail,不然会报错
verifyCodeAuthenticationProvider.setUserDetailsService(userDetailsService());
return verifyCodeAuthenticationProvider;
}

// 通过重写 authenticationManager 方法来提供一个自己的 AuthenticationManager,实际上就是 ProviderManager,在创建 ProviderManager 时,加入自己的 verifyCodeAuthenticationProvider。
@Bean
protected ProviderManager myProviderManager() {
// 可以传List,也可以传可变参数
return new ProviderManager(Arrays.asList(verifyCodeAuthenticationProvider()));
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/code.jpg").permitAll()
.antMatchers("/hello").authenticated()
.antMatchers("/admin").fullyAuthenticated()
.antMatchers("/remember").rememberMe()
.and()
.formLogin()
.permitAll()
.and()
.csrf()
.disable()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}

}

创建VerifyCodeAuthenticationProvider,需要提供 UserDetailService 和 PasswordEncoder 实例。

一毛也是爱~
Kim.Zhang 微信支付

微信支付

  • 文章目录
  • 站点概览
Kim.Zhang

Kim.Zhang

且行且珍惜
94 日志
12 分类
42 标签
E-Mail Weibo
  1. 1. 1. Authentication接口简析
  2. 2. 2. DaoAuthenticationProvider简析
  3. 3. 3. 自定义认证流程
    1. 3.1. 3.1 验证码生成
    2. 3.2. 3.2 提供自定义的ProviderManager
粵ICP备19091267号 © 2019 – 2022 Kim.Zhang | 629k | 9:32
本站总访问量 4 次 | 有 309 人看我的博客啦 |
博客全站共176.7k字
载入天数...载入时分秒...
0%